﻿
using EmperialApps.WeatherSpark.Data;
using EmperialApps.WeatherSpark.Resources;
using Microsoft.Phone.Controls;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Windows;

namespace EmperialApps.WeatherSpark.Internal {

    /// <summary>Manages forecast downloads.</summary>
    internal sealed class ForecastDownloadManager {

        private static readonly HashSet<string> _failures = new HashSet<string>( );
        private readonly PhoneApplicationPage _page;
        private readonly Progress _setProgress;
        private Action _cancelAsync;


        /// <summary>The callback delegate used to record download progress.</summary>
        public delegate void Progress( PhoneApplicationPage page, ProgressKind kind, Localized.Status progress, Localized.FormatArgument args = default(Localized.FormatArgument) );


        /// <summary>Initializes a new instance of the <see cref="ForecastDownloadManager"/> class for the specified page.</summary>
        public ForecastDownloadManager( PhoneApplicationPage page, Progress setProgress ) {
            this._page = page;
            this._setProgress = setProgress ?? Extensions.SetProgress;
        }


        /// <summary>Gets the <see cref="LocationSettings"/> used to download <see cref="Forecast"/>.</summary>
        public LocationSettings ForecastSettings { get; private set; }

        /// <summary>Gets the URI used to download <see cref="Forecast"/>.</summary>
        public string ForecastUri { get; private set; }

        /// <summary>Gets the last downloaded forecast.</summary>
        public Forecast Forecast { get; private set; }

        /// <summary>Gets or sets a value indicating whether to save a downloaded forecast to a file.</summary>
        public bool DisableForecastSave { get; set; }

        /// <summary>Gets a value indicating whether a forecast is being downloaded.</summary>
        public bool Downloading { get; private set; }


        /// <summary>Occurs when a forecast has completed downloading.</summary>
        public event EventHandler<ForecastEventArgs> ForecastDownloadCompleted = delegate { };


        /// <summary>Retrieves a new cancellable web client.</summary>
        public WebClient GetWebClient( ) {
            var client = new WebClient( );
            this._cancelAsync += client.CancelAsync;
            return client;
        }

        /// <summary>Clears the specified client.</summary>
        public void ReturnClient( object sender ) {
            var webClient = sender as WebClient;
            if( webClient != null )
                this._cancelAsync -= webClient.CancelAsync;
        }

        /// <summary>Cancels any outstanding web requests.</summary>
        public void Cancel( ) {
            Action cancelAsync = this._cancelAsync;
            this._cancelAsync = null;

            if( cancelAsync != null ) {
                this._page.GetType( ).Log( "Cancelling open web requests: " + cancelAsync.GetInvocationList( ).Length );
                cancelAsync( );
            }
        }

        /// <summary>Begins the download of the forecast at the specified URI.</summary>
        public bool BeginForecastDownload( LocationSettings locationSettings, string forecastUri, DownloadReason reason ) {
            return this.BeginForecastDownload( locationSettings, forecastUri, reason, Localized.Status.Downloading, default( Localized.FormatArgument ) );
        }

        /// <summary>Begins the download of the forecast at the specified URI.</summary>
        public bool BeginForecastDownload( LocationSettings locationSettings, string forecastUri, DownloadReason reason, Localized.Status downloadMessage, Localized.FormatArgument args ) {
            this._page.GetType( ).Log( "Beginning forecast download for " + reason );
            if( !IsValid( forecastUri ) ) {
                // If performing a refresh, attempt to repair the state of the forecast.
                if( reason != DownloadReason.Subscription )
                    return this.BeginForecastRepair( locationSettings );

                // Otherwise, report subscription error.
                this._setProgress( this._page, ProgressKind.CompletedUnsuccessfully, Localized.Status.DownloadRequestFailure );
                string failure = forecastUri ?? "-" + AppResources.popup_ServerDownloadError_message0fallback;
                if( _failures.Add( failure ) )
                    new Exception( "We were unable to retrieve your forecast from the server: " + failure.Substring( 1 ) ).Report( Localized.ErrorSource.Download );
                else
                    Localized.Popup.ServerDownloadError.Show( failure.Substring( 1 ) );

                this.Forecast = null;
                return false;
            }

            DateTimeOffset currentUpdate = DateTimeOffset.Now;
            if( reason == DownloadReason.AutoRefresh ) {
                DateTimeOffset lastUpdate = locationSettings.LastUpdate;
                if( Settings.DisableForecastAutoRefresh || currentUpdate < lastUpdate + Settings.UpdateLimit ) {
                    this._page.GetType( ).Log( "- Skipping forecast download" );
                    return false;
                }
            }


            this.Downloading = true;
            locationSettings.LastUpdate = currentUpdate;

            this.ForecastSettings = locationSettings;
            this._setProgress( this._page, ProgressKind.Incomplete, downloadMessage, args.AddLogArgument( locationSettings.GetIndex( ) ) );
            this.ForecastSettings = null;

            var downloadInfo = new DownloadInfo( locationSettings, reason, forecastUri );
            this.BeginForecastDownloadCore( downloadInfo );
            return true;
        }

        /// <summary>Attempts to repair the state of the specified forecast.</summary>
        public bool BeginForecastRepair( LocationSettings locationSettings ) {
            this._page.GetType( ).Log( "Repairing forecast " + locationSettings.GetIndex( ) );

            // If download URI is valid, download latest forecast from server to replace value on disk.
            string forecastUri = locationSettings.DownloadUri;
            if( IsValid( forecastUri ) )
                return this.BeginForecastRepairCore( locationSettings, forecastUri );

            // Otherwise, re-generate correct URI.
            this._setProgress( this._page, ProgressKind.Important, Localized.Status.RepairingForecast, locationSettings.GetDisplayName( ) );

            Place place = locationSettings.GetPlace( link: "" );
            locationSettings.Source.GetSourceUrl( place, locationSettings, ( s, u ) => this.BeginForecastRepairCore( s, u ) );
            return true;
        }


        #region Private Members

        private static bool IsValid( string forecastUri ) {
            return !string.IsNullOrEmpty( forecastUri )
                && forecastUri[0] != '-'
                && !forecastUri.Contains( Settings.ServerStorage );
        }

        private ErrorKind ClassifyError( Exception error ) {
            if( error == null )
                return ErrorKind.None;


            if( error.Message == "WebException"
             || error.Message == "The remote server returned an error: NotFound." )
                return ErrorKind.Network;


            if( error is WebException
             && (error.Message.StartsWith( "The remote server returned an error:", StringComparison.Ordinal )
              || error.Message.EndsWith( "The connection was closed unexpectedly.", StringComparison.Ordinal )) )
                return ErrorKind.Transient;

            if( error.InnerException is System.ObjectDisposedException
             || error.InnerException is System.IO.EndOfStreamException
             || error.InnerException is System.Net.Sockets.SocketException )
                return ErrorKind.Transient;

            if( error.InnerException is System.IO.IOException
             && error.InnerException.Message.IndexOf( "forcibly closed by the remote host", StringComparison.Ordinal ) > 0 )
                return ErrorKind.Transient;

            if( error.InnerException is System.Xml.XmlException
             && error.InnerException.Message.StartsWith( "For security reasons DTD is prohibited", StringComparison.Ordinal ) )
                return ErrorKind.Transient;

            if( error.InnerException is System.Xml.XmlException
             && error.InnerException.Message.StartsWith( "Unexpected end of file", StringComparison.Ordinal ) )
                return ErrorKind.Transient;


            if( error.InnerException is System.Xml.XmlException
             && error.InnerException.Message.StartsWith( "'=' is an unexpected token. The expected token is ';'", StringComparison.Ordinal ) )
                return ErrorKind.Persistent;

            if( error.InnerException == null
             && error.Message.Equals( "File did not contain any temperature data." ) )
                return ErrorKind.Persistent;


            return ErrorKind.Unexpected;
        }


        private bool BeginForecastRepairCore( LocationSettings locationSettings, string forecastUri ) {
            if( string.IsNullOrEmpty( forecastUri ) )
                return false;

            locationSettings.DownloadUri = forecastUri;
            return this.BeginForecastDownload( locationSettings, forecastUri, DownloadReason.ManualRefresh, Localized.Status.RepairingForecast, locationSettings.GetDisplayName( ) );
        }

        private void BeginForecastDownloadCore( DownloadInfo downloadInfo ) {
            uint uniquifier = ++Settings.Uniquifier;
            char joiner = downloadInfo.ForecastUri.Contains( '?' ) ? '&' : '?';
            string uniqueUri = string.Format( "{0}{1}{2}={3}", downloadInfo.ForecastUri, joiner, Settings.UniquifierName, uniquifier );

            var address = new Uri( uniqueUri );
            var client = this.GetWebClient( );
            client.OpenReadCompleted += this.OnForecastDownloadOpenReadCompleted;
            client.OpenReadAsync( address, downloadInfo );
        }

        private void OnForecastDownloadOpenReadCompleted( object sender, OpenReadCompletedEventArgs e ) {
            this.ReturnClient( sender );
            if( e.WasCancelled( ) ) {
                this._page.GetType( ).Log( "Download cancelled" );
                this.Downloading = false;
                return;
            }

            var downloadInfo = (DownloadInfo)e.UserState;
            var locationSettings = downloadInfo.LocationSettings;
            Coordinate location = downloadInfo.Location;
            Exception error = e.Error;
            Forecast forecast;

            if( error != null ) {
                forecast = null;
            }
            else {
                int index = Settings.IndexOfLocation( location );
                using( Stream stream = e.Result )
                    if( stream.TryLoadForecast( location, this._page.GetType( ), index, out forecast, out error ) ) {
                        // If the forecast contains a redirect, save and restart download from new URI.
                        string redirectUri;
                        if( forecast.TryGetRedirectUri( out redirectUri ) ) {
                            this._page.GetType( ).Log( "Processing forecast redirect." );
                            var redirectInfo = new DownloadInfo( downloadInfo, redirectUri );
                            this.BeginForecastDownloadCore( redirectInfo );
                            return;
                        }

                        // Otherwise, save updated forecast.
                        forecast = this.DisableForecastSave
                                 ? forecast.UpdateFromFile( this._page.GetType( ), index )
                                 : forecast.SaveToFile( this._page.GetType( ), index );

                        // Update download URI if location has changed.
                        if( downloadInfo.Reason != DownloadReason.Subscription
                         && Settings.MoveLocation( locationSettings, forecast.Location ) )
                            locationSettings.Source.GetSourceUrl(
                                locationSettings.GetPlace( forecast.Location ),
                                locationSettings, ( s, u ) => s.DownloadUri = u ?? s.DownloadUri );
                    }
            }


            this.Downloading = false;
            this.ForecastSettings = locationSettings;
            this.ForecastUri = downloadInfo.ForecastUri;
            this.Forecast = forecast;


            ErrorKind errorKind = ClassifyError( error );

            Localized.Status message;
            switch( errorKind ) {
                case ErrorKind.None:
                    message = 0;
                    break;
                case ErrorKind.Network:
                    message = Localized.Status.NetworkError;
                    break;
                case ErrorKind.Transient:
                    message = Localized.Status.TransientError;
                    break;
                case ErrorKind.Persistent:
                    message = Localized.Status.PersistentError;
                    break;
                case ErrorKind.Unexpected:
                default:
                    message = Localized.Status.UnexpectedError;
                    break;
            }

            ProgressKind kind = ProgressKind.Complete;
            Localized.FormatArgument logArg = default( Localized.FormatArgument );
            if( message != 0 ) {
                kind = ProgressKind.CompletedUnsuccessfully;
                logArg = logArg.AddLogArgument( Environment.NewLine + error );
            }

            this._setProgress( this._page, kind, message, logArg );

            if( errorKind == ErrorKind.Unexpected ) {
                var callers =
                    this.ForecastDownloadCompleted
                        .GetInvocationList( )
                        .Select( d => d.Target == null ? null : d.Target.GetType( ) )
                        .Where( t => t != null && t != typeof( ForecastDownloadManager ) );

                string caller = string.Join( ";", callers );
                error.Report( Localized.ErrorSource.Download, caller );
            }


            this.ForecastDownloadCompleted( this, new ForecastEventArgs( location, forecast, error ) );
        }


        private enum ErrorKind {
            None = 0,
            Unexpected,
            Network,
            Transient,
            Persistent,
        };

        private struct DownloadInfo {
            public readonly Coordinate Location;
            public readonly LocationSettings LocationSettings;
            public readonly DownloadReason Reason;
            public readonly string ForecastUri;

            public DownloadInfo( LocationSettings locationSettings, DownloadReason reason, string forecastUri ) {
                this.Location = locationSettings.Location;
                this.LocationSettings = locationSettings;
                this.Reason = reason;
                this.ForecastUri = forecastUri;
            }

            public DownloadInfo( DownloadInfo downloadInfo, string redirectUri )
                : this( downloadInfo.LocationSettings, downloadInfo.Reason, redirectUri ) { }

            public override string ToString( ) {
                return string.Format( "Performing {0} download for {1} at {2}", this.Reason, this.LocationSettings, this.ForecastUri );
            }
        }

        #endregion

    }

}
